Taeseong Blog

React에서 중복호출을 완벽하게 막는 법

2025-10-15

React중복 호출

이 글은 제가 재밌게 본 시지프님의 블로그 글을 정리하여 작성하고, 실제로 응용한 내용을 정리한 것 입니다. 시지프님의 블로그에 좋은 글이 많으니 한번 들어가보셔도 좋을 것 같아요.
시지프 - React에서 중복호출(aka. 따닥)을 막는 완벽한 방법

들어가며

서비스를 개발하다 보면 한 번만 수행되어야 할 요청이 여러 번 발생하는 상황을 종종 마주하게 됩니다. 예를 들어, 버튼을 빠르게 두 번 클릭해 API가 중복 호출되거나, 댓글이 두 번 등록되는 문제 등이 그렇습니다. 이런 문제는 단순한 UI 버그에 그치지 않고, 서버에 불필요한 부하를 주고 장애 원인 파악을 어렵게 만들기도 합니다.

많은 개발자들은 이를 막기 위해 isLoading 상태를 사용하거나 debounce, throttle 같은 기법을 활용합니다. 하지만 이 방식들은 근본적으로 중복 호출을 완벽하게 차단하지는 못합니다. 왜 그런지 이유를 살펴보고, 실질적으로 중복 호출을 방지할 수 있는 방법을 정리해보겠습니다.

isLoading 상태를 활용한 분기의 한계

가장 흔한 방식은 호출 중임을 나타내는 isLoading 상태를 두고, 이미 로딩 중일 때는 추가 호출을 막는 방식입니다. handler가 호출되면, isLoading 상태를 true로 만들고, promise가 settled 되면, isLoading을 다시 false로 되돌립니다.

function LoadingStateButton({ onClick }: { onClick: () => Promise<void> }) {
  const [isLoading, setIsLoading] = useState(false);

  return (
    <button
      type="button"
      onClick={async () => {
        if (isLoading) {
          return;
        }
        setIsLoading(true);
        await onClick();
        setIsLoading(false);
      }}
    >
      LoadingButton
    </button>
  );
}

언뜻 보면 문제가 없어 보이지만, 리액트 상태의 본질을 생각하면 문제를 짐작할 수 있습니다.

react state는 성능 관리를 위해서, 일정 요청을 모아서 batch 로 상태를 업데이트 합니다. 즉, setState는 비동기 요청입니다. 그러므로 setIsLoading(true)가 즉시 반영되지 않는 짧은 순간 동안 클릭이 한 번 더 발생하면, 중복 호출이 그대로 일어날 수 있습니다. isLoading 상태를 활용한 해결책은 "운 좋게" 막힐 뿐, 근본적으로 중복호출을 막아주지 못 합니다.

tmi: 리액트 18에선 이러한 리액트의 상태변경 주기를 활용할 수 있게, React가 렌더링 작업에 우선순위를 부여하고 필요시 중단 및 재개할 수 있는 concurrent mode가 도입되기도 했죠.

debounce / throttle의 한계

debounce와 throttle은 이벤트 과다 호출을 제어하는 데 유용하지만, “중복된 의도 호출”을 막기 위한 도구는 아닙니다.

debounce 는 정해진 시간동안 발생한 여러 이벤트 중, 앞(leading) 혹은 뒤(trailing)에 하나의 이벤트만 트리거 시키는 방법입니다. 예시 코드를 보시죠.

// lodash.debounce를 react에 적용한 코드

const handleClick = useMemo(() => {
  return debounce(onClick, waitMS, { leading: true, trailing: false });
}, []);

debounce 를 활용한 방식은 해피 케이스에는 문제가 없을 것입니다.

  • API가 빠르게 응답하면 문제가 없어 보일 수 있지만,
  • API가 느리게 응답하면 debounce 대기 시간이 끝난 후 다시 클릭이 가능해져, 결국 중복 호출이 발생합니다.

또한, debounce 대기 시간을 길게 잡으면 실패 후 재시도조차 막아버리는 부작용이 생길 수 있습니다.

여기서 고민이 되는 부분은 waitMS 를 얼마로 설정할 것이냐 입니다. API 는 보통 1초 안에 끝나니까 1초로? 여유롭게 3초로? 이런 직관으로 정할 순 없겠죠. API latency는 서버, DB 상황에 따라서 언제나 달라질 수 있습니다. 이를 고정한다는 것은 엄밀하지 않은 사고입니다.

즉, debounce/throttle은 검색창, 스크롤 등 “과도한 이벤트”를 줄이는 데 적합하지만, “딱 한 번만 실행되어야 할 요청”을 막기에는 적절하지 않습니다.

기법 막을 수 있는 문제 막지 못하는 문제
isLoading 단순 연타 상태 반영 전의 클릭
debounce/throttle 과도한 반복 이벤트 의도된 반복/느린 응답 중 재클릭

Solution

그렇다면 어떻게 해야 근본적으로 중복호출을 막을 수 있을까요? 상태처럼 비동기 업데이트가 아니라, 즉각적인 업데이트 방식이 필요합니다. react에서 즉각적인 변수값 변경을 위해서 사용하는 것은 useRef 입니다. Ref 값은 상태는 아니기 때문에 re-render는 발생시키지 않으면서, 참조값을 즉시 변경시킵니다. 그러므로 중복호출을 완벽히 가드할 수 있습니다.

1) 기본 예시: useRef로 중복 호출 차단

예시 코드를 살펴보겠습니다. isLoading 을 useRef로 선언하여, onClick 의 중복호출을 막고 있습니다.

function LoadingRefButton({ onClick }: { onClick: () => Promise<void> }) {
  const isLoadingRef = useRef(false);

  return (
    <button
      type="button"
      disabled={isLoadingRef.current}
      onClick={async () => {
        if (isLoadingRef.current) {
          return;
        }
        isLoadingRef.current = true;
        await onClick();
        isLoadingRef.current = false;
      }}
    >
      LoadingRefButton
    </button>
  );
}

이 방식은 호출 자체는 완벽하게 막을 수 있지만, 단점이 하나 있습니다. isLoadingRef는 변경되어도 렌더링이 일어나지 않기 때문에 disabled 상태가 화면에 반영되지 않습니다.

그렇다면, 더 나은 해결책을 떠올려봅시다. 완벽한 방어를 위한 조건은 아래 두가지입니다.

  • re-render를 유발한다.
  • 즉각적인 변수값 변경으로, 중복호출을 완전히 방어한다.

1번을 위해서는 상태를 도입해야만 하고, 2번을 위해서는 useRef를 사용하거나, 즉각적인 상태 변경이 필요합니다. Advanced Solution 2가지를 살펴보겠습니다.

Advanced Solution 1: ref + 강제 렌더링

ref 값과 상태를 모두 사용하여 해결하는 예시입니다. ref의 즉각적인 변수값 변경을 통해 호출을 차단하고, 상태는 re-render를 위한 트리거로 사용합니다.

function LoadingRefWithRenderButton({
  onClick,
}: {
  onClick: () => Promise<void>;
}) {
  const isLoadingRef = useRef(false);
  const reRender = useReRenderer();

  return (
    <button
      type="button"
      disabled={isLoadingRef.current}
      onClick={async () => {
        if (isLoadingRef.current) {
          return;
        }
        isLoadingRef.current = true;
        reRender();
        await onClick();
        isLoadingRef.current = false;
        reRender();
      }}
    >
      LoadingRefButton
    </button>
  );
}

function useReRenderer() {
  const [, setState] = useState({});
  return useCallback(() => setState({}), []);
}

이렇게 하면, disabled 상태를 반영하면서, 중복호출을 원천 차단할 수 있습니다.

Advanced Solution 2: flushSync를 이용한 동기 상태 반영

상태 변경을 비동기로 실행하지 않고, 동기적으로 실행하는 방법은 없을까요? 이를 위해서 react18 에서 flushSync API가 나왔습니다.

간단하게 setIsLoading(true) 인 부분만 flushSync로 감싸줍니다. flushSync는 react 의 최적화된 성능관리를 거스르고 re-render 를 강제로 유발하는 API 이기 때문에, 권장되지 않습니다. 그러므로 사용을 최소화 하기 위해서 setIsLoading(false) 에는 flushSync를 사용하지 않습니다.

function LoadingStateButton({ onClick }: { onClick: () => Promise<void> }) {
  const [isLoading, setIsLoading] = useState(false);

  return (
    <button
      type="button"
      disabled={isLoading}
      onClick={async () => {
        if (isLoading) {
          return;
        }
        flushSync(() => setIsLoading(true));
        await onClick();
        setIsLoading(false);
      }}
    >
      LoadingButton
    </button>
  );
}

flushSync를 사용한 방법은 react18 이상에만 적용되는 방법이므로, 범용적인 라이브러리를 사용하는 것은 권장되지 않습니다.

응용하기

앞서 소개드린 솔루션을 응용해서 저는 실제로 프로젝트에 적용했는데요. 워낙 많은 페이지에서 CRUD가 많은 프로젝트다 보니, 많은 곳에 위와 같이 useRef를 raw하게 사용하는 것은 가독성과 유지보수 측면에서 좋지 않겠다 생각했습니다.

그래서 다음과 같이 useLoadingGate 훅을 정의하여 사용했습니다.

'use client';

import { useCallback, useRef, useState } from 'react';

/**
 * 중복 실행 방지 + 강제 리렌더를 한 번에 제공하는 훅
 * - isLoadingRef.current: 현재 실행(로딩) 여부
 * - startLoading(): 이미 실행중이면 false 반환, 아니면 true 반환하면서 실행 시작 + 리렌더
 * - stopLoading으로(): 실행 종료 + 리렌더
 * - guard(asyncFn): startLoading/stopLoading으로을 알아서 감싸주는 안전 실행 래퍼
 */
export function useLoadingGate(initial = false) {
  const isLoadingRef = useRef<boolean>(initial);
  const [, setTick] = useState(0);
  const reRender = useCallback(() => setTick((t) => t + 1), []);

  const startLoading = useCallback(() => {
    if (isLoadingRef.current) return false;
    isLoadingRef.current = true;
    reRender();
    return true;
  }, [reRender]);

  const stopLoading으로 = useCallback(() => {
    if (!isLoadingRef.current) return;
    isLoadingRef.current = false;
    reRender();
  }, [reRender]);

  // 콜백 기반 실행 래퍼
  const guard = useCallback(
    async <T>(fn: () => Promise<T>): Promise<T | undefined> => {
      if (!startLoading()) return;
      try {
        return await fn();
      } finally {
        stopLoading으로();
      }
    },
    [startLoading, stopLoading으로]
  );

  return {
    isLoadingRef,
    isLoading: isLoadingRef.current,
    reRender,
    startLoading,
    stopLoading으로,
    guard,
  };
}

위 훅을 설명드리면 다음과 같습니다.

구성 요소 역할 설명
isLoadingRef 현재 작업 중인지 나타내는 내부 상태. useRef로 관리되며, 값이 바뀌어도 자동 리렌더되지 않음
reRender() 리렌더를 강제로 발생시키는 함수 (useState의 tick 업데이트 활용)
startLoading() 작업 시작 시 호출. 이미 작업 중이라면 false 반환 → 중복 실행 방지
stopLoading() 작업 종료 시 호출. 로딩 상태를 해제하고 리렌더
guard(asyncFn) start → async 작업 → stop 흐름을 자동으로 감싸는 안전 실행 래퍼

그리고 위 훅을 API 호출 하는 곳에서 다음과 같이 사용하고 있습니다.

import React from "react";
import { useLoadingGate } from "./useLoadingGate"; // 당신의 훅 경로

function ApiComponent() {
  const { isLoading, guard } = useLoadingGate();

  const apiCall = async () => {
    console.log("API 시작");
    await new Promise((resolve) => setTimeout(resolve, 2000));
    console.log("API 끝");
  };

  const handleClick = () => {
    guard(apiCall); // 이미 로딩 중이면 호출되지 않음
  };

  return (
    <div>
      <button onClick={handleClick} disabled={isLoading}>
        {isLoading ? "처리 중..." : "API 호출하기"}
      </button>
    </div>
  );
}

export default ApiComponent;

마치며

React에서 중복 호출을 막기 위해 흔히 사용되는 방법의 한계를 알아보고, ref를 통한 새로운 접근 방법을 알아보았습니다. 이번 글이 보다 완성도 높은 서비스 구현에 도움이 되길 바랍니다.

저는 프로젝트에서 react-query의 useMutation을 사용해 API 호출을 하는 곳에 useLoadingGate 훅을 도입하였는데요, 팀원분이 isFetching으로 막을 수 있지 않냐는 코멘트가 달렸었습니다. 리액트쿼리에서 제공하는 isFetching도 마찬가지로 리액트 state입니다. 때문에 앞서 설명드린 state가 중복 호출을 막지 못하는 동일한 이유와 문제를 가지고 있습니다.

useLoadingGate 훅을 도입한 PR에 남긴 코멘트, 그리고 이후에 팀원들과 나눴던 대화를 끝으로 마치겠습니다.